/* * Copyright 2016 Sam Sun <me@samczsun.com> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.samczsun.skype4j.formatting; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.select.Elements; import java.awt.*; import java.util.*; import java.util.function.BiConsumer; import java.util.function.BiPredicate; import java.util.function.Consumer; /** * Represents a rich text component. This component can be formatted. * All children will also have the specified formats. */ public class RichText extends Text { public enum Format { BOLD("b", RichText::withBold), ITALIC("i", RichText::withItalic), UNDERLINE("u", RichText::withUnderline), STRIKE_THROUGH("s", RichText::withStrikethrough), CODE("pre", RichText::withCode), BLINK("blink", RichText::withBlink); private final String tagName; private final Consumer<RichText> apply; Format(String tagName, Consumer<RichText> apply) { this.tagName = tagName; this.apply = apply; } public String getTagName() { return this.tagName; } public Consumer<RichText> getApplicator() { return this.apply; } public String getOpenTag() { return "<" + this.tagName + ">"; } public String getCloseTag() { return "</" + this.tagName + ">"; } } private static final Map<String, BiConsumer<RichText, Element>> TAG_APPLIER = Collections.unmodifiableMap( new HashMap<String, BiConsumer<RichText, Element>>() {{ Arrays.stream(Format.values()).forEach(format -> put(format.getTagName(), (text, elem) -> format.getApplicator().accept(text))); put("font", (text, elem) -> { if (elem.hasAttr("size")) { text.withSize(Integer.parseInt(elem.attr("size"))); } if (elem.hasAttr("color")) { text.withColor(Color.decode(elem.attr("color"))); } }); put("a", (text, elem) -> text.withLink(elem.attr("href"))); put("#text", (text, elem) -> { // How do we handle this? }); }} ); private static final Map<String, BiPredicate<RichText, Element>> TAG_TEST = Collections.unmodifiableMap( new HashMap<String, BiPredicate<RichText, Element>>() {{ Arrays.stream(Format.values()).forEach(format -> put(format.getTagName(), (text, elem) -> text.hasFormat(format))); put("font", (text, elem) -> { boolean equal = true; if (elem.hasAttr("size") && text.size >= 0) { equal = equal && text.size == Integer.parseInt(elem.attr("size")); } else if (!elem.hasAttr("size") && text.size == -1) { equal = equal && true; } else { equal = false; } if (equal) { if (elem.hasAttr("color") && text.color != null) { String color = elem.attr("color"); equal = equal && text.color.equals(color.substring(color.indexOf('#') + 1)); } else if (!elem.hasAttr("color") && text.color == null) { equal = equal && true; } else { equal = false; } } return equal; }); put("a", (text, elem) -> elem.attr("href").equals(text.link)); put("#text", (text, elem) -> { // How do we handle this? return false; }); }} ); private final Set<Format> formats = EnumSet.noneOf(RichText.Format.class); private String link = null; private String color = null; private int size = -1; private RichText next; private RichText previous; private String text; RichText(String text) { this(null, text); } RichText(RichText previous, String text) { this.previous = previous; this.text = text; } public String getText() { return this.text; } private RichText setText(String text) { this.text = text; return this; } private void appendText(String text) { this.text += text; } /** * Make this text component bold * * @return The same RichText instance */ public RichText withBold() { this.formats.add(Format.BOLD); return this; } /** * Make this text component underlined * * @return The same RichText instance */ public RichText withUnderline() { this.formats.add(Format.UNDERLINE); return this; } /** * Make this text component italicized * * @return The same RichText instance */ public RichText withItalic() { this.formats.add(Format.ITALIC); return this; } /** * Make this text component struck through * * @return The same RichText instance */ public RichText withStrikethrough() { this.formats.add(Format.STRIKE_THROUGH); return this; } /** * Make this text component blink * * @return The same RichText instance */ public RichText withBlink() { this.formats.add(Format.BLINK); return this; } /** * Make this text component link to the supplied URL * * @param link The URL to link to * @return The same RichText instance */ public RichText withLink(String link) { this.link = link; return this; } /** * Give this text component a color * * @param color The color to use * @return The same RichText instance */ public RichText withColor(Color color) { this.color = Integer.toHexString(color.getRGB()); this.color = this.color.substring(2, this.color.length()); return this; } /** * Give this text component a size * * @param size The size to use * @return The same RichText instance */ public RichText withSize(int size) { this.size = size; return this; } /** * Make this text component code-formatted * * @return The same RichText instance */ public RichText withCode() { this.formats.add(Format.CODE); return this; } public boolean hasFormat(Format format) { return this.formats.contains(format); } public RichText append(String text) { return append(text, false); } public RichText append(String text, boolean clearFormat) { this.next = new RichText(this, Text.parseEmojis(text)); if (!clearFormat) { this.next.copyFormat(this); } return this.next; } private void copyFormat(RichText from) { this.formats.addAll(from.formats); this.link = from.link; this.color = from.color; this.size = from.size; } public String write() { return this.previous != null ? this.previous.write() : this.write0(); } private String write0() { StringBuilder output = new StringBuilder(); java.util.List<Format> formats = Arrays.asList(RichText.Format.values()); formats.stream() .filter(format -> this.previous == null || !this.previous.formats.contains(format)) .filter(this.formats::contains) .map(Format::getOpenTag) .forEach(output::append); boolean font = size != -1 || color != null; boolean openFont = font; boolean openLink = this.link != null; if (this.previous != null) { openLink = openLink && !this.link.equals(this.previous.link); openFont = openFont && (this.size != this.previous.size || !Objects.equals(this.color, this.previous.color)); } if (openFont) { output.append("<font "); if (size != -1) { output.append("size=\"").append(size).append("\" "); } if (color != null) { output.append("color=\"#").append(color).append("\" "); } output.setLength(output.length() - 1); output.append(">"); } if (openLink) { output.append("<a href=\"").append(this.link).append("\">"); } output.append(this.text); boolean closeLink = this.link != null; boolean closeFont = font; if (this.next != null) { closeLink = closeLink && !this.link.equals(this.next.link); closeFont = closeFont && (this.size != this.next.size || !Objects.equals(this.color, this.next.color)); } if (closeLink) { output.append("</a>"); } if (closeFont) { output.append("</font>"); } Collections.reverse(formats); formats.stream() .filter(format -> this.next == null || !this.next.formats.contains(format)) .filter(this.formats::contains) .map(Format::getCloseTag) .forEach(output::append); if (this.next != null) { output.append(this.next.write0()); } return output.toString(); } @Override public String toString() { return this.previous != null ? this.previous.toString() : this.write(); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; if (this.previous != null) { return this.previous.equals(o); } RichText text = (RichText) o; while (text.previous != null) { text = text.previous; } return this.equals0(text); } private boolean equals0(RichText richText) { if (!this.formats.equals(richText.formats)) return false; if (this.size != richText.size) return false; if (Objects.equals(this.link, richText.link)) return false; if (Objects.equals(this.color, richText.color)) return false; return this.next == null ? richText.next == null : this.next.equals0(richText.next); } @Override public int hashCode() { return this.previous != null ? this.previous.hashCode() : this.hashCode0(); } public int hashCode0() { int result = (this.formats.contains(Format.BOLD) ? 1 : 0); result = 31 * result + (this.formats.contains(Format.ITALIC) ? 1 : 0); result = 31 * result + (this.formats.contains(Format.UNDERLINE) ? 1 : 0); result = 31 * result + (this.formats.contains(Format.STRIKE_THROUGH) ? 1 : 0); result = 31 * result + (this.formats.contains(Format.CODE) ? 1 : 0); result = 31 * result + (this.formats.contains(Format.BLINK) ? 1 : 0); result = 31 * result + (this.link != null ? this.link.hashCode() : 0); result = 31 * result + (this.color != null ? this.color.hashCode() : 0); result = 31 * result + this.size; result = 31 * result + (this.next != null ? this.next.hashCode0() : 0); return result; } public static RichText fromHtml(String html) { Document doc = Jsoup.parse(html); doc.outputSettings().prettyPrint(false); RichText root = new RichText(""); parse(root, doc.getElementsByTag("body").get(0)); return root; } private static RichText parse(RichText root, Node node) { RichText current = root; if (node instanceof Element) { Element elem = (Element) node; applyTag(current, elem); String inner = elem.html(); Elements children = elem.children(); if (children.size() > 0) { String[] parts = new String[children.size() + 1]; int i = 0; int index = 0; for (Element child : children) { int startChild = inner.indexOf("<" + child.tag().toString(), index); int endChild = startChild + child.outerHtml().length(); parts[i++] = inner.substring(index, startChild); index = endChild; } parts[i] = inner.substring(index); Element last = elem; for (int j = 0; j < parts.length; j++) { if (hasTag(root, last)) { current.appendText(parts[j]); } else { current = current.append(parts[j], true); current.copyFormat(root); } if (j < children.size()) { Element child = children.get(j); if (!hasTag(current, child)) { current = current.append("", true); current.copyFormat(root); } current = parse(current, child); last = child; } } } else { current.appendText(inner); } } return current; } private static void applyTag(RichText text, Element tag) { RichText.TAG_APPLIER.getOrDefault(tag.tagName(), (t, elem) -> { }).accept(text, tag); } private static boolean hasTag(RichText text, Element tag) { return RichText.TAG_TEST.getOrDefault(tag.tagName(), (t, elem) -> true).test(text, tag); } }